Skip to content

缓存穿透

1.1 定义

一个请求,请求的数据在缓存层没有,在MySQL层也没有,这条请求“穿”过去了

1.2 解决方法

  1. Bloom Filter:在进行请求Redis之前,先由布隆过滤器判断请求的数据是否存在,其认定为不存在的数据一定不存在,认定为存在的数据可能会存在。这样就可以过滤掉大部分无效的请求,减轻缓存和MySQL的压力。
  2. 缓存null值:经过缓存层和MySQL层查询到数据不存在之后,给数据缓存一个null值到Redis,设置一个短的TTL,之后再来请求该数据,返回null即可。

1.3 两种解决方案对比

特性缓存空对象布隆过滤器
开发难度极低(几行代码)中等(需处理初始化与同步)
内存消耗较高(取决于无效 Key 数量)极低
数据一致性易于保持(通过 TTL)较难维护(删除操作麻烦)
防御效果防御==重复==的无效请求防御海量、==不重复==的无效请求

我们日常项目只需要使用缓存空对象的方法即可,因为现代内存(如 Redis 内存)相对廉价,相比之下,维护一套布隆过滤器的复杂度和出错率(例如数据同步失败导致的拦截错误)更让架构师头疼。

1.4 缓存穿透产生的场景

1. 恶意攻击(最常见原因)

这是缓存穿透被反复提及的核心原因。攻击者会利用自动化工具,通过程序脚本伪造海量的、数据库中根本不存在的请求。

  • 随机 ID 攻击: 假设你的商品 ID 是自增的(如 1001, 1002),攻击者可以发起诸如 id=-1id=999999999 或者 id=uuid-xxxx-xxxx 这种随机字符串的请求。
  • 目的: 攻击者的目标不是为了获取数据,而是为了绕过 Redis 直接冲击数据库。如果数据库并发处理能力较弱,瞬间的大量无效请求会导致数据库连接池耗尽,从而引发系统崩溃。

2. 爬虫抓取

一些不规范的爬虫在爬取网站数据时,可能会根据某种规律尝试“盲猜” URL 或接口参数。

  • 遍历尝试: 爬虫可能会尝试遍历所有的数字 ID。如果你的业务中存在大量已逻辑删除的数据,或者 ID 序列中间有很大的空洞(例如分布式 ID 产生的空隙),爬虫就会产生大量命中不了数据库的请求。

3. 业务逻辑漏洞或数据下线

有时候,这种请求是由于系统前后的状态不一致导致的:

  • 数据大范围下线: 比如某个促销活动结束,或者一批商品被紧急下架。虽然前端页面可能不再显示入口,但如果用户收藏了链接,或者旧版本的 App 缓存了跳转路径,用户依然会发起这些 ID 的请求。
  • 输入校验不严: 前端或 API 网关如果没有对参数进行基础的合法性校验(例如 ID 必须是正整数、长度限制等),错误的参数就会透传到后端逻辑中。

1.5 实际代码开发

1.5.1 缓存空对象

  1. 缓存空对象的正确做法是缓存一个约定好的字符串,如 """null",因为Redis的Value是不能直接缓存Java中的null的

  2. 代码示例:

    缓存 ""

    java
    // 1. 从 Redis 查询
    String json = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(json)) {
        // 存在且有真实数据,反序列化返回
        return JSONUtil.toBean(json, User.class);
    }
    
    // 3. 关键点:判断命中目标是否是“空对象” (防止缓存穿透)
    // 如果 json 不为 null,说明它是我们之前存入的 ""(空字符串)
    if (json != null) {
        return null; // 直接返回 null,不再查询数据库
    }
    
    // 4. 数据库查询
    User user = getById(id);
    
    // 5. 数据库也不存在
    if (user == null) {
        // 【正确做法】:存入空字符串 "",并设置较短的过期时间(例如 2 分钟)
        stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES);
        return null;
    }
    
    // 6. 数据库存在,写入缓存
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(user), 30L, TimeUnit.MINUTES);
    return user;

    缓存 "NULL"

    java
    // 1. 从 Redis 查询
    String json = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(json)) {
        // 3. 关键点:判断命中目标是否是“空对象” (防止缓存穿透)
        // 这里json不是我们缓存的"NULL"就是真实数据
        if ("NULL".equals(json)) {
            return null; // 直接返回 null,不再查询数据库
        }
        // 存在且有真实数据,反序列化返回
        return JSONUtil.toBean(json, User.class);
    }
    
    
    // 4. 数据库查询
    User user = getById(id);
    
    // 5. 数据库也不存在
    if (user == null) {
        // 【正确做法】:存入空字符串 "",并设置较短的过期时间(例如 2 分钟)
        stringRedisTemplate.opsForValue().set(key, "NULL", 2L, TimeUnit.MINUTES);
        return null;
    }
    
    // 6. 数据库存在,写入缓存
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(user), 30L, TimeUnit.MINUTES);
    return user;

1.6 其他解决方案

  1. 增加ID复杂度,避免被猜测ID规律,并进行合理的ID校验,拒绝攻击者或爬虫的访问
  2. 增加数据基础格式的校验
  3. 权限校验
  4. 对热点key限流